iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0

claude_10

系列:奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲
今日工具:Claude Code
今日進度domain/story 狀態機 + table-driven tests 全綠,domain/save 介面與記憶體版倉儲

前言

Day 9 把雲端地基畫好了,今天回到程式碼,做這款遊戲「有靈魂」的那一半:劇情。Day 4 已經把三條命運之路、三種結局與五個關鍵抉擇寫成 data/story/graph.json。那份 JSON 是資料;今天要做的是讀它、走它、記住玩家走到哪裡的狀態機

我對這個狀態機有三個要求。一、純函數——輸入一個 State、輸出一個新的 State,絕不修改輸入,這樣存檔、重試、測試都不會互相污染。二、節點型別決定允許的操作——在戰鬥節點呼叫「選擇」必須報錯,而不是默默做出奇怪的事。三、路線判定要有明確的平手優先序,不能靠 map 迭代順序這種隨機性。這三條後來都變成了測試,其中一條還真的抓到 bug。

一、圖的形狀:五種節點,五種離開方式

graph.json 的節點只有五種型別,每種型別只允許一種「離開」方式:

type 離開方式 對應方法
dialogue next Advance
choice choices[i].next,套用 effects、檢查 requires Choose(idx)
battle onWin / onLose ReportBattle(level, won)
route Route(state)routes map Advance
ending

route 是 Day 4 定的特殊節點 n_final_gate{"type":"route","routes":{"vigil":"n_b6_vigil","blaze":"n_b6_blaze","truce":"n_b6_truce"}}。它不顯示任何文字,只是依 flags 最高者決定進哪一場終章戰鬥。把這個判斷做成節點而不是寫死在程式裡,是為了讓劇情作者(也就是我自己)之後可以在 JSON 裡加第四條路線而不用動 Go。

graph.go 裡的 Graph.Validate() 會在載入時檢查每個 nextonWinonLoseroutes 都指向存在的節點——打錯一個節點 id,服務啟動就失敗。Day 25 會再寫一支 storylint 找「到不了的節點」與「死路」,今天先擋最基本的「指向不存在的節點」。

二、狀態機:machine.go

Prompt(給 Claude Code)
「在 internal/domain/story 實作 StateMachineState{Node, Flags map[string]int, Ember int, Cleared []string}。方法:CurrentAdvance(dialogue/route)、Choose(idx)(只允許 choice,檢查 requires、套用 effects)、ReportBattle(level, won)(只允許 battle,且 level 要吻合)、Route(vigil/blaze/truce,平手優先序 vigil > truce > blaze)。所有方法不可修改輸入的 State。錯誤用 sentinel error(ErrNotChoice…)方便上層 errors.Is。」

核心片段:

// Choose 只允許在 choice 節點呼叫,會檢查 requires 並套用 effects。
func (m *Machine) Choose(s State, idx int) (State, error) {
	n := m.Current(s)
	if n.Type != NodeChoice {
		return s, ErrNotChoice
	}
	if idx < 0 || idx >= len(n.Choices) {
		return s, ErrBadChoice
	}
	c := n.Choices[idx]
	for k, v := range c.Requires {
		if (k == "ember" && s.Ember < v) || (k != "ember" && s.Flags[k] < v) {
			return s, ErrRequirement
		}
	}
	s = clone(s) // 從這裡開始才碰 s,前面的錯誤路徑回傳的是原封不動的輸入
	for k, v := range c.Effects {
		if k == "ember" {
			s.Ember += v
		} else {
			s.Flags[k] += v
		}
	}
	s.Node = c.Next
	return s, nil
}

ReportBattle 的形狀一樣:先確認節點是 battlelevel 吻合,再 clone,贏了把關卡 id 加進 Cleared 並走 onWin,輸了走 onLoseAdvance 處理 dialogue(走 next)與 route(用 Route(s) 的結果查 routes map)。

Route 只有六行,但值得多看一眼。var routePriority = []string{"vigil", "truce", "blaze"} 既是候選清單也是平手優先序:從第一個開始,只有「嚴格大於」才換人,平手自然保留排在前面的路線。Claude 第一版用 for k, v := range s.Flags 找最大值,我請它改掉:Go 的 map 迭代順序是刻意隨機的,平手時同一份存檔會在兩次呼叫得到不同結局。這種 bug 在單元測試裡可能一百次才出現一次,卻會在某個玩家面前準時發生。

把三個方法畫成狀態圖,五個 sentinel error 各自守在對應的轉移上:

claude_10_diagram_01

另一個設計選擇是 State 完全用可序列化的基本型別——字串、map、int、slice——沒有指標、沒有指回 Graph 的參照。這讓它可以直接映射進資料庫:Day 13 的 DynamoDB item 直接把 Flags 存成 Map 屬性(attributevalue.MarshalMap),Cleared 存成 List of String——不是 String Set,因為 SS 不保順序也不允許空集合。也讓 clone 只需要複製一個 map 與一個 slice。狀態機本身不持有任何狀態,Machine 唯一的欄位是唯讀的 *Graph,所以同一個 Machine 可以被所有請求共用,不需要鎖。

三、Table-driven tests:把三條要求變成測試

Prompt(給 Claude Code)
「為 Machine 寫 table-driven tests,用一張小型測試圖(不要讀真實 JSON)。至少涵蓋:requires 不足、索引越界、在 battle 節點呼叫 Choose 要回 ErrNotChoice、ReportBattle 的 level 不吻合、輸了留在原節點、Route 的三種平手情境。」

{"全零 → vigil", map[string]int{}, "vigil"},
{"blaze 領先", map[string]int{"vigil": 1, "blaze": 3, "truce": 2}, "blaze"},
{"vigil=truce 平手 → vigil", map[string]int{"vigil": 2, "truce": 2}, "vigil"},
{"truce=blaze 平手 → truce", map[string]int{"truce": 2, "blaze": 2}, "truce"},

這是 TestMachine_RouteTieBreak 的表格,四列就把平手規則釘死;每列用 t.Run(tc.name, ...) 跑成子測試,失敗時會直接印出「vigil=truce 平手 → vigil」這種人話。TestMachine_Choose 用同樣的表格式寫了六個案例:守望 +1、燃盡 +1 且得薪火、薪火不足不可談判(ErrRequirement)、薪火足夠可談判、索引越界(ErrBadChoice)、在 battle 節點呼叫(ErrNotChoice)。每一列同時檢查錯誤、落點節點與薪火數,一張表就把 Day 4 的規則全部釘死。

TestMachine_ReportBattle 檢查 level 不吻合回 ErrLevelMismatch、輸了留在 n_b1Cleared 不變、贏了走到 n_gateCleared 多一筆。TestMachine_AdvanceRoute 則是 Day 4 那個 route 節點的驗收:把 flags 設成 truce: 2, blaze: 2 站在 n_gate 上呼叫 Advance,必須落在 routes["truce"] 指向的結局節點——平手優先序與 route 查表兩件事一次測完。

Claude 額外補了一支我沒要求的 TestMachine_ChooseDoesNotMutateInput,而它真的抓到 bug:第一版的 clone 沒有複製 Flags map,只複製了 struct 本身,Choose 之後原本的 State 也被加了分。這是 Go 裡最容易犯的錯之一——struct 複製是淺的,map 與 slice 只複製了指標。修法是 clone 裡明確 make 一個新 map 逐一複製。

$ go test ./internal/domain/story -v
--- PASS: TestMachine_Choose (0.00s)                  (6 個子測試)
--- PASS: TestMachine_ChooseDoesNotMutateInput (0.00s)
--- PASS: TestMachine_ReportBattle (0.00s)
--- PASS: TestMachine_RouteTieBreak (0.00s)           (4 個子測試)
--- PASS: TestMachine_AdvanceRoute (0.00s)
ok      emberhold/api/internal/domain/story     0.370s

測試用的是一張手寫的 testGraph(),八個節點涵蓋五種型別,而不是讀真實的 graph.json。理由是真實劇情圖會一直改(Day 23、25 都會動它),測試如果綁著它,每改一句對白就要修測試;小圖只測規則,不測內容。真實 JSON 的完整性由 Graph.Validate() 在啟動時守,Day 25 再用 E2E 走三條路線到三個結局。

四、存檔:先定介面,倉儲晚點換

internal/domain/save/save.go 只有兩個東西:Save 實體(ID uuid.UUIDPlayerNameState story.StateCreatedAtUpdatedAt,JSON tag 全部 camelCase)與 Repository 介面——Create(ctx, *Save)Get(ctx, uuid.UUID) (*Save, error)Update(ctx, *Save),外加一個 ErrNotFound sentinel。

三個方法就夠了——沒有 List、沒有 Delete,因為這款遊戲目前沒有任何情境需要它們。介面越小,Day 13 換實作時要對的東西越少;需要的時候再加,而不是先猜。Claude 第一版給了六個方法(含 FindByPlayerNameDelete),我請它砍到三個,並在介面上方註解「新增方法前先確認有 usecase 需要」。

adapter/memory/save_repo.gomap + sync.RWMutex 實作這個介面,先讓 usecase 跑得起來;Day 13 換成 DynamoDB 時 usecase 一行都不用改——這就是 Day 8 畫那條依賴線的回報。介面放在 domain/save 而不是 adapter,因為「存檔長什麼樣、能做什麼」是領域知識,「存在哪裡」才是實作細節;依賴方向也因此正確——adapter 依賴 domain 的介面,而不是反過來。

另一個小決定:State 整包放進 Save,而不是攤平成欄位,讓 usecase 能直接 machine.Choose(sv.State, idx),狀態機完全不知道存檔的存在。

小結

今天的程式碼量不大,但它是這款遊戲跟一般塔防不同的核心。三個心得:一、把「不可變」當成硬規則並讓 Claude 幫忙寫測試盯著,它真的抓到 map 淺複製的 bug;二、任何「取最大值」的邏輯都要明講平手規則,否則 Go 的 map 會替你擲骰子;三、先定 Repository 介面再寫記憶體版,讓明後天的 API 與 DynamoDB 可以各走各的。狀態機是純函數這件事,明天寫模擬器、後天開 worktree 時都會再感謝一次。

今日產出

  • [x] backend/internal/domain/story/{graph,machine,machine_test}.go(5 個測試、14 個子測試)
  • [x] backend/internal/domain/save/save.go
  • [x] backend/internal/adapter/memory/save_repo.go

明日預告

Day 11:塔防戰鬥核心:波次生成器與敵人 AI 邏輯,Go 的 goroutine 如何模擬戰場。


上一篇
Day 09:Terraform 開疆:沒有 VPC 的雲端疆域(DynamoDB、S3、IAM 最小權限)
系列文
奇幻塔防開發實錄:用 Claude 打造一款有靈魂的塔防遊戲10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言